Skip to content

feat(config): implement global config to wire up config command#1789

Open
Hweinstock wants to merge 8 commits into
aws:refactorfrom
Hweinstock:feat/global-config
Open

feat(config): implement global config to wire up config command#1789
Hweinstock wants to merge 8 commits into
aws:refactorfrom
Hweinstock:feat/global-config

Conversation

@Hweinstock

@Hweinstock Hweinstock commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Problem

The CLI is missing a way to view and control global configuration. There is an existing config command, but its not wired up.

Solution

  • implement a new top level globalConfig/ module that defines an accessor for global config. The global config is decoupled from the source of the data via an injected dependency.
  • move src/router/schemas.tsx to top level /parsing directory so that logic can be re-used.
  • global config is type-safe based on the global config schema using json dot notation. For example, valid keys are telemetry, telemetry.enabled, etc. All of which are derived automatically from the schema definition.
  • wire up config command to be output JSON responses.

Testing

  • wrote unit tests at the config command level to test e2e, rather than testing implementation directly.
  • e2e testing using existing config from CLI also works.
    Ex.
> bun run start config
{
  "telemetry": {
    "enabled": false,
    "endpoint": "https://telemetry.agentcore.aws.dev"
  },
}
> bun run start config telemetry.endpoint 
"https://telemetry.agentcore.aws.dev"

> bun run start config telemetry.endpoint https://example.com
"https://example.com"

Future Work

  • add error types for telemetry, and easier reasoning (few todo comments on this).
  • add logic to setup installationId for telemetry (getOrPut).

@github-actions github-actions Bot added agentcore-harness-reviewing AgentCore Harness review in progress and removed agentcore-harness-reviewing AgentCore Harness review in progress labels Jul 17, 2026
@codecov-commenter

codecov-commenter commented Jul 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.52941% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.04%. Comparing base (951044e) to head (548979d).

Files with missing lines Patch % Lines
src/globalConfig/schemas.tsx 95.65% 3 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##           refactor    #1789      +/-   ##
============================================
+ Coverage     93.91%   94.04%   +0.13%     
============================================
  Files           118      123       +5     
  Lines          6423     6621     +198     
============================================
+ Hits           6032     6227     +195     
- Misses          391      394       +3     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@Hweinstock
Hweinstock force-pushed the feat/global-config branch 8 times, most recently from db54dba to b39f26a Compare July 17, 2026 16:53
@Hweinstock

Copy link
Copy Markdown
Contributor Author

typecheck fixed in #1756 (comment)

@Hweinstock
Hweinstock marked this pull request as ready for review July 17, 2026 16:55
@Hweinstock Hweinstock changed the title (wip)feat(config): implement global config to wire up config command feat(config): implement global config to wire up config command Jul 17, 2026
Comment thread src/globalConfig/accessor.tsx Outdated
Comment thread src/parsing/index.tsx Outdated
Comment thread src/handlers/config/handler.tsx Outdated
@Hweinstock
Hweinstock force-pushed the feat/global-config branch from b39f26a to 4b819e1 Compare July 20, 2026 13:25
@Hweinstock
Hweinstock marked this pull request as draft July 20, 2026 13:25
@Hweinstock
Hweinstock force-pushed the feat/global-config branch 4 times, most recently from d60cf0d to 7fdb445 Compare July 20, 2026 13:43
@Hweinstock
Hweinstock marked this pull request as ready for review July 20, 2026 13:46
@Hweinstock

Copy link
Copy Markdown
Contributor Author

rebased onto create command work.

@AlexanderRichey AlexanderRichey left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel that the level of complexity this PR introduces is out of proportion with the functionality gained. Let's sync on this and see if we can develop a simpler approach.

@AlexanderRichey

Copy link
Copy Markdown
Contributor

I have some prior art here that might be useful to look at: https://github.com/AlexanderRichey/yagss/blob/main/internal/builder/config.go

@Hweinstock
Hweinstock marked this pull request as draft July 21, 2026 00:28
@Hweinstock

Copy link
Copy Markdown
Contributor Author

converted to draft while I rework based on offline discussion from above, will take out of draft when ready for another review.

@Hweinstock
Hweinstock force-pushed the feat/global-config branch 3 times, most recently from c9c3f17 to c0c3d7a Compare July 21, 2026 00:47
@Hweinstock
Hweinstock force-pushed the feat/global-config branch 5 times, most recently from 1ab82be to 3d8bebb Compare July 21, 2026 15:05
@Hweinstock

Copy link
Copy Markdown
Contributor Author

Reworked the implementation to simplify:

  • config access is now done directly on a typed object rather than a type safe datastore interface.
  • partially invalid configs are no longer supported.
  • mid-execution config updates are no longer respected.
  • no longer leverages shared coercion functions from router, instead leverages zod preprocessing.

@Hweinstock
Hweinstock marked this pull request as ready for review July 21, 2026 15:07
@Hweinstock

Copy link
Copy Markdown
Contributor Author

taking back into draft to address some comments on #1794, that are relevant here as well. Specifically,

  • use type instead of interface for non-extended shape.
  • prefer classes over direct definitions.

@Hweinstock
Hweinstock marked this pull request as draft July 21, 2026 17:43

@AlexanderRichey AlexanderRichey left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a nice improvement over the initial rev, but I still think there's a lot of room to simplify. Let me sketch what I have in mind.

First, ReadWriteJson needs to become generic to justify itself as an interface. The current JsonDataSource is way too specific—and it's name is a bit confusing since it can only be used to read and write one file. Here's an implementation, thanks to Claude, that should handle everything we need to.

import { readFileSync, writeFileSync } from "node:fs";
import { z } from "zod";

class DeserializationError extends Error {
  constructor(path: string, options?: { cause?: unknown }) {
    super(`Failed to deserialize JSON at "${path}"`, options);
    this.name = "DeserializationError";
  }
}

interface ReadWriteJson {
  read<T>(path: string, schema: z.ZodType<T>): T;
  write<T>(path: string, data: T): void;
}

class JsonFileStore implements ReadWriteJson {
  read<T>(path: string, schema: z.ZodType<T>): T {
    const contents = readFileSync(path, "utf-8");

    let parsed: unknown;
    try {
      parsed = JSON.parse(contents);
    } catch (cause) {
      // The file wasn't valid JSON.
      throw new DeserializationError(path, { cause });
    }

    const result = schema.safeParse(parsed);
    if (!result.success) {
      // The file was valid JSON but didn't match the shape of T.
      throw new DeserializationError(path, { cause: result.error });
    }
    return result.data;
  }

  write<T>(path: string, data: T): void {
    const contents = JSON.stringify(data, null, 2);
    writeFileSync(path, contents, "utf-8");
  }
}

With this, the implementation of GlobalConfigAccessor should become straightforward.

// ---------------------------------------------------------------------------
// Global config schema and types
// ---------------------------------------------------------------------------

export const globalConfigSchema = z.object({
  telemetry: z
    .object({
      enabled: z.boolean().optional(),
      endpoint: z.string().optional(),
      audit: z.boolean().optional(),
    })
    .optional(),
  installationId: z.uuid().optional(),
});

/** Inferred shape of the global config from {@link globalConfigSchema}. */
export type GlobalConfig = z.infer<typeof globalConfigSchema>;

/** Reads and writes global CLI configuration. */
export interface GlobalConfigAccessor {
  /** Returns the current global config. */
  get(): Promise<GlobalConfig>;
  /** Validates and persists a new config. Throws on invalid shape. */
  set(updated: GlobalConfig): Promise<GlobalConfig>;
}

// ---------------------------------------------------------------------------
// Accessor implementation
// ---------------------------------------------------------------------------

export class DefaultGlobalConfigAccessor implements GlobalConfigAccessor {
  constructor(
    private readonly store: ReadWriteJson,
    private readonly path: string,
  ) {}

  async get(): Promise<GlobalConfig> {
    try {
      return this.store.read(this.path, globalConfigSchema);
    } catch (err) {
      if (isFileNotFound(err)) {
        // No config yet (first run, fresh install). An empty object is a
        // valid GlobalConfig because every field in the schema is optional.
        // Or throw some error here.
        return {};
      }
      throw err;
    }
  }

  async set(updated: GlobalConfig): Promise<GlobalConfig> {
    // Even though `updated` is typed as GlobalConfig, we re-validate at
    // runtime: the type is only a compile-time guarantee, and callers can
    // defeat it (e.g. a value cast with `as`, or data that originated as
    // `unknown`). parse() throws a ZodError on invalid shape, satisfying
    // the interface's "throws on invalid shape" contract.
    const validated = globalConfigSchema.parse(updated);
    this.store.write(this.path, validated);
    return validated;
  }
}

function isFileNotFound(err: unknown): boolean {
  return (
    typeof err === "object" &&
    err !== null &&
    "code" in err &&
    (err as { code?: unknown }).code === "ENOENT"
  );
}

Now, all you'd have to do is wire this stuff up in the handler chain using DI.

const store = new JsonFileStore();
const accessor = new DefaultGlobalConfigAccessor(store, "/path/to/config.json");

Note: We might want to adjust ReadWriteJson so that it's async, which would be more consistent with the whole codebase.

@Hweinstock
Hweinstock force-pushed the feat/global-config branch 4 times, most recently from 23f6a98 to 0c54fd8 Compare July 21, 2026 22:04
@Hweinstock

Hweinstock commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Aligned implementation closer with proposal. swapped to async, changed some names, and swapped the set argument to be untyped to avoid having to lie to the typesystem to pass the parameter.

@Hweinstock
Hweinstock marked this pull request as ready for review July 21, 2026 22:06
@Hweinstock
Hweinstock force-pushed the feat/global-config branch 3 times, most recently from d73b36d to e393ae5 Compare July 21, 2026 22:21
@Hweinstock
Hweinstock force-pushed the feat/global-config branch from e393ae5 to 548979d Compare July 21, 2026 22:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants